/**
 * Copyright (c) 2007, Blue Hole Software. All rights reserved.
 * Code licensed under the Apache 2.0 License:
 * http://www.apache.org/licenses/
 */
package org.bhf.service;

import com.google.inject.Inject;
import com.google.inject.Injector;
import org.bhf.facilities.ProjectInjectors;
import org.bhf.json.ConversionError;
import org.bhf.json.JSONParser;
import org.bhf.json.ValueFormatException;
import org.bhf.providers.ccache.ComponentStateCache;
import org.bhf.providers.router.DefaultURLRouter;
import org.bhf.service.view.StatusProcessView;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Type;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;

/**
 * The RequestManager is a single threaded coordinator for invoking service/component methods, handling
 * exceptions, etc. This is an internal class.
 * @exclude
 */
public class RequestManager
{
    private static Map<String,Method> methodCache = Collections.synchronizedMap( new HashMap<String,Method>() );

    @Inject private Injector            injector;
    @Inject private ComponentStateCache componentCache;
    @Inject private CSRTokenFacility    csrTokenFacility;

    private DefaultURLRouter.RouteMatch route;
    private String                      stateJSON;
    private Integer                     stateVersion;
    private String                      argJSON;
    private String                      csrToken;

    private Object                      service;
    private ServiceGroup                serviceGroup;

    public void     setCsrTokenFacility( final CSRTokenFacility csrTokenFacility )
    {
        this.csrTokenFacility = csrTokenFacility;
    }

    public void     setComponentCache( final ComponentStateCache componentCache )
    {
        this.componentCache = componentCache;
    }

    public void     setRoute( final DefaultURLRouter.RouteMatch route )
    {
        this.route = route;
    }

    public void     setStateJSON( final String stateJSON )
    {
        this.stateJSON = stateJSON;
    }

    public void     setStateVersion( final Integer stateVersion )
    {
        this.stateVersion = stateVersion;
    }

    public void     setArgJSON( final String argJSON )
    {
        this.argJSON = argJSON;
    }

    public void     setCsrToken( final String csrToken )
    {
        this.csrToken = csrToken;
    }

    /**
     * Process the request for which this RequestManager has been initialized.
     *
     * @return Will not be <code>null</code>
     * @throws Exception Any exception thrown by the infrastructure or service. The only 'special'
     *  exceptions are an IllegalRequestException which triggers the HTTP_BAD_REQUEST handling in the
     *  ControllerServlet or the UnknownPageException for a 404.
     */
    ProcessView     process()
        throws Exception
    {
        final ProcessView pv = processInernal();
        if( pv != null )
            injector.injectMembers( pv );
        return pv;
    }

    /**
     * Process the request for which this RequestManager has been initialized.
     *
     * @return Will not be <code>null</code>
     * @throws Exception Any exception thrown by the infrastructure or service. The only 'special'
     *  exceptions are an IllegalRequestException which triggers the HTTP_BAD_REQUEST handling in the
     *  ControllerServlet or the UnknownPageException for a 404.
     */
    // TODO JSON exceptions, etc. are leaky back to browser
    private ProcessView     processInernal()
        throws Exception
    {
        // 0) ServiceGroup
        {
            serviceGroup = ServiceGroup.getServiceGroupByName( route.getServiceGroup() );
            if( serviceGroup == null )
            {
                Package.log.log( Level.SEVERE, "ServiceGroup " + route.getServiceGroup() + " has not been created" );
                    throw new UnknownPageException( route.getServiceGroup() );
            }
        }

        // 1) Service
        {
            if( Package.log.isLoggable( Level.INFO ) )
                Package.log.log( Level.INFO,
                    "RequestManager.process() - Processing a request "
                    + "for serviceGroup \"" + serviceGroup.getName()
                    + "\" and service \"" + route.getService() + "\""
                );
            // We want to use the Project's Injector for the service
            service = serviceGroup.newService( route.getService(), ProjectInjectors.getProjectInjector() );
            if( service == null )
                throw new UnknownPageException( route.getService() );
        }

        // 2) Handle component state
        {
            if( route.getId() != -1 ) // This is how we detect a component hit, vs. a service, w/o a dependency on Component
            {
                if( stateVersion == null && stateJSON == null )
                    throw new IllegalRequestException( "Component state version or state must be given in the URL" );

                // If we have the complete state, use it. Otherwise, try the cache
                if( stateJSON != null )
                {
                    final String conitionedState = conditionJSONObject( stateJSON );
                    final JSONParser parser = new JSONParser();
                    service = parser.parseObject( service, conitionedState );
                }
                else // Try cache, on miss request a resend w/ state
                {
                    final Object cachedComponent = componentCache.recallComponent(
                        service.getClass().getName(), route.getId(), stateVersion
                    );
                    if( cachedComponent != null )
                    {
                        service = cachedComponent;
                    }
                    else
                    {
                        if( Package.log.isLoggable( Level.INFO ) )
                            Package.log.log( Level.INFO,
                                "RequestManager.process() - Component cache miss "
                                + "for serviceGroup \"" + serviceGroup.getName() + "\""
                                + "\" and service \"" + Service.Util.getName( service.getClass() ) + "\""
                            );
                        return new StatusProcessView( Constants.HTTP_STATUS_REQUEST_STATE );
                    }
                }
            }
            else
            {
                // If we have the complete state, use it. Otherwise, try the cache
                if( stateJSON != null )
                {
                    final String conitionedState = conditionJSONObject( stateJSON );
                    final JSONParser parser = new JSONParser();
                    service = parser.parseObject( service, conitionedState );
                }
            }
        }

        // 3) Invoke invocation handler
        {
            final InvocationRequest invocationRequest = findInvocationHandler( route.getMethod() );
                  Object result;

            // 4) Return result
            try
            {
                try
                {
                    // About time for a security check or two...
                    if( !ServiceMethod.Util.isCrossSiteMethod( invocationRequest.method ) )
                    {
                        if( !csrTokenFacility.validateToken( csrToken, route.getServiceGroup(), route.getService() ) )
                        {
                            Package.log.log( Level.WARNING, "Bad CSRF token: " + csrToken );
                            throw new SecurityException();
                        }
                    }

                    {
                        final Object[]      args    = new Object[ invocationRequest.argumentTypes.length ];
                        final JSONParser    parser  = new JSONParser();

                        for( int i = 0; i < args.length; i++ )
                        {
                            if( argJSON != null)
                            {
                                try
                                {
                                    args[i] = parser.parseObject(
                                        invocationRequest.argumentTypes[i],
                                        null,
                                        conditionJSONArgument( invocationRequest.argumentTypes[i], argJSON )
                                    );
                                }
                                catch( ValueFormatException vfe )
                                {
                                    for( final ConversionError error : vfe.getErrors() )
                                    {
                                        Package.log.log( Level.WARNING, "JSON: unable to convert '" + error.getValue() + "' into property '" + error.getPath() + "'" );
                                    }

                                    throw vfe;
                                }
                            }
                            else
                            {
                                args[i] = null;
                            }
                        }

                        result = invocationRequest.method.invoke( invocationRequest.target, args );

                        // This is the only way a ProcessView can tell a void result from a null result w/o access to the Method
                        if( result == null && invocationRequest.method.getReturnType().equals( Void.TYPE ) )
                        {
                            result = Constants.VOID;
                        }

                        if( result != null && result instanceof ProcessView )
                            return (ProcessView)result;

                        return ServiceMethod.Util.getView( service, invocationRequest.method, result );
                    }
                }
                catch( InvocationTargetException ite )
                {
                    final Throwable t = ite.getTargetException();
                    if( t instanceof Exception ) throw (Exception)t;
                    if( t instanceof Error ) throw (Error)t;
                    throw ite;
                }
            }
            catch( Exception e )
            {
                e.printStackTrace( );

                // Try to handle the exception with the component, then the serviceGroup
                try
                {
                    final InvocationRequest handler = findHandler( service, invocationRequest.target, e.getClass() );
                    if( handler != null )
                    {
                        result = handler.method.invoke( handler.target, e );
                        return (ProcessView)result; // Guaranted by findHandler()
                    }
                }
                catch( InvocationTargetException ite2 ) {} // Handler blew up, revert to original exception

                // Punt - never made it with the method or handler...
                throw e;
            }
        }
    }

    //
    // Private
    //

    /**
     * Find an exception handler by looking at the target object, the Service, and then the Service's ServiceGroup.
     *
     * @param service The Service (shadows instance variable)
     * @param target The target of the service method invocation
     * @param tClass The class of the exception that needs handling
     * @return null if a handler could not be found.
     */
    private InvocationRequest   findHandler( final Object service, final Object target, final Class<? extends Throwable> tClass )
    {
        Method  closestMethod = null;
        Object  o = null;
        Method  method;
        int[]   distance = { Integer.MAX_VALUE };

        method = findHandler( target.getClass(), tClass, distance );
        if( method != null )
        {
            closestMethod = method;
            o = target;
        }
        method = findHandler( service.getClass(), tClass, distance );
        if( method != null )
        {
            closestMethod = method;
            o = service;
        }
        for( ServiceGroup aServiceGroup = serviceGroup; aServiceGroup != null; aServiceGroup = aServiceGroup.getParentServiceGroup() )
        {
            method = findHandler( aServiceGroup.getClass(), tClass, distance );
            if( method != null )
            {
                closestMethod = method;
                o = aServiceGroup;
            }
        }

        if( closestMethod != null )
        {
            return new InvocationRequest( closestMethod, o, new Type[] { tClass } );
        }

        return null;
    }

    private Method              findHandler( final Class<?> clazz, final Class<? extends Throwable> tClass, final int[] distance )
    {
        Method result = null;

        for( final Method method : clazz.getMethods() )
        {
            final int modifiers = method.getModifiers();
            final Class<?>[] args = method.getParameterTypes();

            if( Modifier.isStatic( modifiers ) )    continue;
            if( !Modifier.isPublic( modifiers ) )   continue;
            if( args == null || args.length != 1 )  continue;
            if( !ProcessView.class.isAssignableFrom( method.getReturnType() ) ) continue;
            if( !"handle".equals( method.getName() ) ) continue;

            if( args[0].isAssignableFrom( tClass ) )
            {
                final int d = measureDistance( args[0], tClass );
                if( d < distance[0] )
                {
                    distance[0] = d;
                    result = method;
                }
            }
        }

        return result;
    }

    private int                 measureDistance( final Class<?> to, final Class<?> from )
    {
        if( to == from )
            return 0;

        int distance = Integer.MAX_VALUE/2;

        if( from.getSuperclass() != null && from.getSuperclass() != Object.class )
            distance = Math.min( distance, measureDistance( to, from.getSuperclass() ) + 1 );
        for( final Class<?> ifc : from.getInterfaces() )
        {
            distance = Math.min( distance, measureDistance( to, ifc ) );
        }

        return distance;
    }

    /**
     * Find the service method by walking down the property path looking for an appropriate method.
     *
     * @param invocationHandlerPath The property path + '.' + methodName
     * @return Will not be null
     * @throws Exception Method was not found or an exception was through by a getter while walking the
     *      property path.
     */
    private InvocationRequest   findInvocationHandler( final String invocationHandlerPath )
        throws Exception
    {
        final String[] elements = invocationHandlerPath.split( "\\." );

        // Walk the path (normally this doesn't happend because service methods are not usually nested)
        Object target = service;
        for( int i = 0; i < elements.length - 1; i++ )
        {
            final String element = elements[i];
            try
            {
                final String methodName = "get" + Character.toUpperCase( element.charAt( 0 ) ) + element.substring( 1 );
                final Method accessor = target.getClass().getMethod( methodName );

                if( Modifier.isStatic( accessor.getModifiers() ) )
                    throw new IllegalRequestException( target.getClass().getName() + '.' + methodName + " is static" );
                if( !Modifier.isPublic( accessor.getModifiers() ) )
                    throw new IllegalRequestException( target.getClass().getName() + '.' + methodName + " is not public" );

                final String targetName = target.getClass().getName(); // Save for exception
                target = accessor.invoke( target );
                if( target == null )
                    throw new IllegalRequestException( "null property at " + element + " on " + targetName );
            }
            catch( NoSuchMethodException e )
            {
                throw new IllegalRequestException( "Unable to find getter for property " + element + " on " + target.getClass().getName() );
            }
            catch( IllegalAccessException e )
            {
                throw new IllegalRequestException( "Unable to access property " + element + " on " + target.getClass().getName() );
            }
            catch( InvocationTargetException e )
            {
                final Throwable t = e.getCause();
                if( t instanceof Error ) throw (Error)t;
                if( t instanceof Exception ) throw (Exception)t;
                throw e;
            }
        }

        final String methodName = elements[elements.length-1];
        final String key = target.getClass().getName() + ',' + methodName;

        // Check cache first...
        {
            final Method method = RequestManager.methodCache.get( key );
            if( method != null )
            {
                return new InvocationRequest( method, target, method.getGenericParameterTypes() );
            }
        }

        // Check the method
        {
            for( final Method method : target.getClass().getMethods() )
            {
                if(
                    !Modifier.isStatic( method.getModifiers() ) &&
                    Modifier.isPublic( method.getModifiers() ) &&
                    method.getName().equals( methodName ) &&
                    ServiceMethod.Util.isServiceMethod( method )
                )
                {
                    final Type[] argumentTypes = method.getGenericParameterTypes();

                    if( argumentTypes.length <= 1 )
                    {
                        RequestManager.methodCache.put( key, method );
                        return new InvocationRequest( method, target, argumentTypes );
                    }
                }
            }

            throw new IllegalRequestException( "Unable to find handler " + methodName + " on " + target.getClass().getName() );
        }
    }

    /**
     * Add opening and closing braces, if needed, to make a valid JSON object
     * @param json May be null -> returns null
     * @return A braced JSON object or null if json is null
     */
    private String      conditionJSONObject( String json )
    {
        if( json == null ) return null;
        json = json.trim();
        if( json.length() == 0 ) return null;
        if( json.charAt(0) != '{' )
            return '{' + json + '}';
        else
            return json;
    }

    private String      conditionJSONArgument( final Type type, String json )
    {
        if( json == null ) return null;

        if( String.class.equals( type ) )
        {
            if( !json.startsWith( "\"" ) )
                json = "\"" + json;
            if( !json.endsWith( "\"" ) )
                json = json + "\"";
        }

        return json;
    }

    /**
     * Represents an invocation request, whether a service method or exception handler.
     */
    private static class InvocationRequest
    {
        final Method    method;
        final Object    target;
        final Type[]    argumentTypes;


        public InvocationRequest( final Method method, final Object target, final Type[] argumentTypes )
        {
            this.method         = method;
            this.target         = target;
            this.argumentTypes  = argumentTypes;
        }
    }

    //
    // Inner Classes
    //

    /**
     * Indicate that the service (page) is not known.
     * @exclude
     */
    static class UnknownPageException extends Exception
    {
        private static final long serialVersionUID = 1L;

        private UnknownPageException( final String message )
        {
            super( message );
        }
    }
}
